20 — 物理

Three.js Journey · Bruno Simon · 难度:简单

20 — 物理 课程海报

介绍

物理可以是你添加到 WebGL 体验里最酷的功能之一。人们喜欢摆弄物体,看着它们碰撞、倒塌、下落和弹跳,就像我的作品集里那样:https://bruno-simon.com/

给项目添加物理有很多种方式,这取决于你想实现什么。你可以用一些数学计算和像 Raycaster 这样的解决方案自己造物理,但如果你想要带有张力、摩擦、弹跳、约束、枢轴等的真实物理,而且全部都在 3D 空间里,你最好用一个库。

理论

思路很简单。我们要创建一个物理世界。这个物理世界纯粹是理论层面的,我们看不见它。但在这个世界里,物体会下落、碰撞、摩擦、滑动等等。

当我们创建了一个 Three.js mesh 时,我们也会在物理世界里创建那个 mesh 的一个版本。如果我们在 Three.js 里造一个盒子,我们也会在物理世界里造一个盒子。

然后,在每一帧,渲染任何东西之前,我们让物理世界更新它自己;我们取物理物体的坐标(位置和旋转),并把它们应用到对应的 Three.js mesh 上。

仅此而已。最难的其实是把我们的代码组织成一个体面的结构。这正是分道扬镳的地方。每个开发者都有自己的习惯,而且这也取决于你想做什么,以及物理可以变得有多复杂。

开始的时候,我们会简单地只创建球体和盒子。

库

有多个可用的库。首先,你必须决定你需要的是 3D 库还是 2D 库。虽然你可能会认为必须是 3D 库,因为 Three.js 全都是关于 3D 的,但你可能是错的。2D 库通常性能好得多,如果你能把你的体验物理归结为 2D 碰撞,你最好用 2D 库。

一个例子是如果你想做一个台球游戏。球可以互相碰撞并在墙上反弹,但你可以把一切都投影到一个 2D 平面上。你可以在物理世界里把球设计成圆,墙面就是简单的矩形。确实,你将无法做到击打球的底部、让它跳过其他球这种花活。

一个这样完成项目的绝佳例子是 Ouigo Let's play,作者是 Merci Michel。他们用了 2D 物理库,因为每次碰撞和动画都可以在 2D 空间里表示。

3D 物理

对于 3D 物理,有三个主要的库:

Ammo.js

Cannon.js

Oimo.js

Rapier

2D 物理

对于 2D 物理,有很多库,但这里是最受欢迎的:

Matter.js

P2.js

Planck.js

Box2D.js

Rapier

这节课我们不会用 2D 库,但 2D 库的代码会与 3D 库的代码非常相似。主要区别是你需要更新的轴。

已经有一些尝试把 Three.js 与像 Physijs 这样的库结合的方案。但我们不会使用这些方案中的任何一种,以获得更好的学习体验,并更好地理解底下发生了什么。

虽然 Ammo.js 是最常用的库,尤其是和 Three.js 一起用的时候,正如你可以在这些示例里看到的,但我们会选 Cannon.js。这个库在我们的项目里更容易实现,也更易用。

导入 Cannon.js

要往我们的项目里添加 Cannon.js,我们首先需要添加依赖。

在你的终端里,在项目文件夹中,运行这个命令 npm install cannon。

我们现在可以在 JavaScript 里用一个经典的 import 导入 Cannon.js 了:

import CANNON from 'cannon'

我们需要的一切都可在 CANNON 变量里找到。

设置

我们的初始项目由一个放在平面上的球体组成,而且已经从美学的角度启用了阴影。

基础

世界

首先,我们需要创建一个 Cannon.js World:

/**
 * Physics
 */
const world = new CANNON.World()

我们可以做一个没有重力的太空 WebGL 体验,但让我们脚踏实地,添加重力。你可以用 gravity 属性改变这个值,它是一个 Cannon.js Vec3。

Cannon.js 的 Vec3 就像 Three.js 的 Vector3。它有 x、y 和 z 属性,但也有一个 set(...) 方法:

world.gravity.set(0, - 9.82, 0)

我们用 - 9.82 作为这个值,因为它是地球上的重力常数,但如果你想让东西落得更慢,或者如果你的场景发生在火星上,你可以用任何其他值。

物体

因为我们的场景里已经有一个球体,让我们在我们 Cannon.js 的 World 里创建一个球体。

要做到这一点,我们必须创建一个 Body。Body 就是那些会下落并与其他 Body 碰撞的对象。

在我们能创建一个 Body 之前,我们必须决定一个形状。有很多可用的基本形状,比如 Box、Cylinder、Plane 等等。我们会用一个 Sphere,和我们的 Three.js 球体有相同的半径:

const sphereShape = new CANNON.Sphere(0.5)

然后我们可以创建我们的 Body,并指定一个质量和位置:

const sphereBody = new CANNON.Body({
    mass: 1,
    position: new CANNON.Vec3(0, 3, 0),
    shape: sphereShape
})

最后,我们可以用 addBody(...) 把 Body 添加到世界:

world.addBody(sphereBody)

什么都不会发生,因为我们仍然需要更新我们的 Cannon.js 世界,并相应地更新我们的 Three.js 球体。

更新 Cannon.js 世界与 Three.js 场景

要更新我们的 World,我们必须使用 step(...)。这个方法背后的代码很难理解,我们这节课不会解释它,但你可以在这篇文章里找到更多相关内容。

要让它正常工作,你必须提供一个固定的时间步长、自上一次 step 以来经过的时间,以及世界为了赶上一个潜在延迟可以应用的迭代次数。

我们不会解释什么是时间步长,但因为我们要我们的体验以 60fps 运行,我们会用 1 / 60。别担心,这个体验在更高和更低帧率的设备上都会以同样的速度运行。

迭代次数由你决定,但如果体验顺畅运行,那就没那么重要。让我们用 3。

对于增量时间,就要稍微难一点了。我们需要计算自上一帧以来经过了多长时间。不要使用 Clock 类的 getDelta() 方法。你不会得到想要的结果,而且你会把类的内部逻辑搞乱。

要得到正确的增量时间,我们需要把上一帧的 elapsedTime 从当前的 elapsedTime 里减去:

const clock = new THREE.Clock()
let oldElapsedTime = 0

const tick = () =>
{
    const elapsedTime = clock.getElapsedTime()
    const deltaTime = elapsedTime - oldElapsedTime
    oldElapsedTime = elapsedTime

    // ...
}

我们终于可以更新我们的世界了:

const tick = () =>
{
    // ...

    // Update physics
    world.step(1 / 60, deltaTime, 3)
}

仍然,似乎什么都一动不动。但我们的 sphereBody 正在下落,你可以通过在世界更新后记录它的位置看到这一点:

world.step(1 / 60, deltaTime, 3)
    console.log(sphereBody.position.y)

我们现在要做的,是用 sphereBody 的坐标更新我们的 Three.js sphere。有两种方式。你可以分别更新每个 position 属性:

sphere.position.x = sphereBody.position.x
    sphere.position.y = sphereBody.position.y
    sphere.position.z = sphereBody.position.z

或者你可以用 copy(...) 方法把所有属性一次性复制:

sphere.position.copy(sphereBody.position)

copy(...) 在很多类里都有,比如 Vector2、Vector3、Euler、Quaternion,甚至在像 Material、Object3D、Geometry 这样的类里也有。

lesson demo video preview

你应该最终看到你的球体在下落。问题是我们的球体似乎穿过了地板。那是因为那个地板存在于 Three.js 场景里,但不存在于 Cannon.js 世界里。

我们可以简单地用一个 Plane 形状添加一个新的 Body,但我们不想让我们的地板受重力影响而下落。换句话说,我们要我们的地板是静态的。要让一个 Body 静态,把它 mass 设为 0:

const floorShape = new CANNON.Plane()
const floorBody = new CANNON.Body()
floorBody.mass = 0
floorBody.addShape(floorShape)
world.addBody(floorBody)
lesson demo video preview

正如你看到的,这一次我们做的事情相当不同。我们创建了一个不带参数的 Body,之后才设置那些参数。结果是一样的,我们这样做唯一的原因是为了教学。一个有意思的事情是,你可以创建一个由多个 Shape 组成的 Body。这对于复杂但实心的物体可能很有用。

你应该看到球体朝一个方向弹出去(很可能是朝相机)。这不是想要的结果。原因是我们的平面默认朝着相机。我们需要旋转它,就像我们在 Three.js 里旋转地板那样。

用 Cannon.js 旋转比用 Three.js 稍微难一点,因为你必须使用 Quaternion。有多种方式旋转 Body,但必须通过它的 quaternion 属性。我们会用 setFromAxisAngle(...)。

第一个参数是一条轴。你可以把它想象成一根穿过 Body 的尖刺。第二个参数是角度。它是你绕着那根尖刺旋转 Body 的程度。

floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(- 1, 0, 0), Math.PI * 0.5)
lesson demo video preview

我们设置这条轴,就好像它是一根沿着负 x 轴(相对相机来说是左侧)穿过 Body 的尖刺,然后我们把角度设为 Math.PI * 0.5(四分之一圈)。

你现在应该看到球体下落,然后停在地板上。

我们不需要用 Cannon.js 的地板更新 Three.js 的地板,因为这个物体不会动。

接触材质

正如你看到的,球没有弹跳太多。那是默认行为,我们可以用 Material(不是 Three.js 的 Material)和 ContactMaterial 改变它。

一个 Material 只是一个引用。你可以给它一个名字,并把它与一个 Body 关联起来。思路是为你场景里每一种材质类型创建一个 Material。

假设你世界里的所有东西都是用塑料做的。那样的话,你只需要创建一个材质,并把它命名为 'default' 或 'plastic'。如果你的场景里有多种类型的材质,比方说一种材质给地板,一种给球。那么,你应该创建不同的 Material,并给它们像 'concrete' 和 'plastic' 这样的名字。

你本可以叫它们 'ground' 和 'ball'。但如果你想把同样的材质用于墙面和其他物体比如立方体,用一个叫 'ground' 的材质可能会不方便。

在你创建球体和地板之前,创建这两个 Material:

const concreteMaterial = new CANNON.Material('concrete')
const plasticMaterial = new CANNON.Material('plastic')

现在我们有 Material 了,我们必须创建一个 ContactMaterial。它是两个 Material 的组合,并包含物体碰撞时要用的属性。

前两个参数是 Material。第三个参数是一个对象 {},包含两个重要属性:friction(摩擦)系数(摩擦有多大)和 restitution(恢复)系数(弹跳有多厉害)——两者的默认值都是 0.3。

创建之后,用 addContactMaterial(...) 方法把 ContactMaterial 添加到世界:

const concretePlasticContactMaterial = new CANNON.ContactMaterial(
    concreteMaterial,
    plasticMaterial,
    {
        friction: 0.1,
        restitution: 0.7
    }
)
world.addContactMaterial(concretePlasticContactMaterial)

混凝土和塑料之间没有太多摩擦,但如果你让一个塑料球落到混凝土地板上,你会看到它弹跳很多。

我们现在可以在 Body 上使用我们的 Material 了。你可以在实例化 Body 时直接传入 Material,或者在之后用 material 属性。为了教学,我们两种都做:

const sphereBody = new CANNON.Body({
    // ...
    material: plasticMaterial
})

// ...

const floorBody = new CANNON.Body()
floorBody.material = concreteMaterial
lesson demo video preview

你应该看到球在停下来之前弹跳很多次。我们看不到 friction 在起作用,因为我们的球完美笔直地落在地板上,大部分时间都在空中。

拥有不同的 Material 并为每种组合创建 ContactMaterial 会被证明非常繁琐。为了简化一切,让我们把我们两个 Material 替换成一个默认的,并在每个 Body 上使用它:

const defaultMaterial = new CANNON.Material('default')
const defaultContactMaterial = new CANNON.ContactMaterial(
    defaultMaterial,
    defaultMaterial,
    {
        friction: 0.1,
        restitution: 0.7
    }
)
world.addContactMaterial(defaultContactMaterial)

// ...

const sphereBody = new CANNON.Body({
    // ...
    material: defaultMaterial
})

// ...

floorBody.material = defaultMaterial
lesson demo video preview

我们得到相同的结果。

我们甚至可以更进一步,把我们材质设为 World 的默认材质。要做到这一点,只需把 defaultContactMaterial 赋值给 world 的 defaultContactMaterial 属性:

world.defaultContactMaterial = defaultContactMaterial

我们现在可以移除或注释掉对 sphereBody 和 floorBody 的材质赋值了。

施加力

有很多方式可以给一个 Body 施加力:

因为使用"force"方法会导致速度变化,所以我们不用"impulse"方法

让我们用 applyLocalForce(...) 在开始时给我们的 sphereBody 施加一个小小的冲量:

sphereBody.applyLocalForce(new CANNON.Vec3(150, 0, 0), new CANNON.Vec3(0, 0, 0))
lesson demo video preview

你可以看到球向右弹跳并滚动。

现在让我们用 applyForce(...) 施加一点风。因为风是持续存在的,我们应该在每一帧、更新 World 之前施加这个力。要正确施加这个力,施加点应该是 sphereBody.position:

const tick = () =>
{
    // ...

    // Update physics
    sphereBody.applyForce(new CANNON.Vec3(- 0.5, 0, 0), sphereBody.position)

    world.step(1 / 60, deltaTime, 3)

    // ...
}
lesson demo video preview

处理多个物体

处理一两个物体很容易,但管理几十个物体可能一团糟。我们需要把事情自动化一点。

首先,移除或注释掉 sphere、sphereShape 和 sphereBody。

用函数自动化

首先,让我们用一个会同时添加 Three.js 和 Cannon.js 版本对象的函数,改进我们创建球体的方式。

作为这个函数的参数,我们只传入 radius 和 position,但随你自由添加其他参数,比如 mass、material、subdivisions 等等。

/**
 * Utils
 */
const createSphere = (radius, position) =>
{
}

现在我们可以创建 Three.js 的 Mesh:

const createSphere = (radius, position) =>
{
    // Three.js mesh
    const mesh = new THREE.Mesh(
        new THREE.SphereGeometry(radius, 20, 20),
        new THREE.MeshStandardMaterial({
            metalness: 0.3,
            roughness: 0.4,
            envMap: environmentMapTexture,
            envMapIntensity: 0.5
        })
    )
    mesh.castShadow = true
    mesh.position.copy(position)
    scene.add(mesh)
}

以及 Cannon.js 的 Body:

const createSphere = (radius, position) =>
{
    // ...

    // Cannon.js body
    const shape = new CANNON.Sphere(radius)

    const body = new CANNON.Body({
        mass: 1,
        position: new CANNON.Vec3(0, 3, 0),
        shape: shape,
        material: defaultMaterial
    })
    body.position.copy(position)
    world.addBody(body)
}

注意,我们本可以在 CANNON.Body 实例化时直接使用 position。

我们可以移除之前创建的球体,然后调用 createSphere(...)(在创建 Cannon.js 世界和 Three.js 场景之后)。别忘了移除 tick() 函数里的球体更新:

createSphere(0.5, { x: 0, y: 3, z: 0 })
lesson demo video preview

正如你看到的,位置不一定要是 Three.js 的 Vector3 或 Cannon.js 的 Vec3,我们可以简单地用一个带 x、y 和 z 属性的对象(我们很幸运)。

你应该看到球体悬停在地板上方,但遗憾的是,它不再动了。这完全正常,因为我们移除了那段把 Cannon.js Body 的 position 应用到 Three.js Mesh 的 position 的代码。

使用一个物体数组

为了处理这一部分,我们要创建所有需要更新的对象的数组。然后我们会把新创建的 Mesh 和 Body 放在一个对象里,加入那个数组:

const objectsToUpdate = []

const createSphere = (radius, position) =>
{
    // ...

    // Save in objects to update
    objectsToUpdate.push({
        mesh: mesh,
        body: body
    })
}

你可以像这样写最后这部分(在 JavaScript 里,当变量名相同的时候,不需要指定属性):

objectsToUpdate.push({ mesh, body })

我们现在可以在 tick() 函数里(在更新世界之后立刻)遍历那个数组,并把每个 body.position 复制到 mesh.position:

const tick = () =>
{
    // ...

    world.step(1 / 60, deltaTime, 3)

    for(const object of objectsToUpdate)
    {
        object.mesh.position.copy(object.body.position)
    }
}
lesson demo video preview

球体应该再次开始下落了。

添加到 lil-gui

让我们玩玩,给我们的 lil-gui 添加一个 createSphere 按钮。问题是,使用 gui.add(...) 方法时,第一个参数应该是一个对象,第二个参数应该是一个属性名。不幸的是,我们的 createSphere 方法不在一个对象里,而且还需要往它里面传参数。这种情况会经常发生。一个不那么坏的解决方案是创建一个对象,它唯一的用途就是把这些游离的函数作为属性放进去:

const gui = new GUI()
const debugObject = {}

然后在需要的时候往里面添加函数(在创建 createSphere 函数之后):

debugObject.createSphere = () =>
{
    createSphere(0.5, { x: 0, y: 3, z: 0 })
}

最后,我们可以往 lil-gui 添加这个新的 createSphere 属性:

gui.add(debugObject, 'createSphere')
lesson demo video preview

如果你点击新创建的 createSphere 按钮,你应该看到球体一个接一个地堆叠。那是因为球体正好出现在相同的位置。让我们添加一些随机性:

debugObject.createSphere = () =>
{
    createSphere(
        Math.random() * 0.5,
        {
            x: (Math.random() - 0.5) * 3,
            y: 3,
            z: (Math.random() - 0.5) * 3
        }
    )
}
lesson demo video preview

球体下起雨来了!

尽量别把你的电脑烧掉;这段代码需要优化。

优化

因为 Three.js Mesh 的几何体和材质是相同的,我们应该把它们从 createSphere 函数里拿出来。问题是我们在用 radius 创建几何体。一个简单的解决方案是把 SphereGeometry 的半径固定为 1,然后缩放 Mesh:

const sphereGeometry = new THREE.SphereGeometry(1, 20, 20)
const sphereMaterial = new THREE.MeshStandardMaterial({
    metalness: 0.3,
    roughness: 0.4,
    envMap: environmentMapTexture,
    envMapIntensity: 0.5
})
const createSphere = (radius, position) =>
{
    // Three.js mesh
    const mesh = new THREE.Mesh(sphereGeometry, sphereMaterial)
    mesh.castShadow = true
    mesh.scale.set(radius, radius, radius)
    mesh.position.copy(position)
    scene.add(mesh)

    // ...
}

你应该得到相同的结果。

添加盒子

既然我们的球体工作得很好,让我们对盒子做同样的流程。

要创建一个盒子,我们必须用一个 BoxGeometry 和一个 Box 形状。小心;这两个参数不一样。一个 BoxGeometry 需要一个 width、一个 height 和一个 depth。与此同时,一个 Box 形状需要一个 halfExtents。它由一个 Vec3 表示,对应一条从盒子中心出发、连向盒子其中一个角落的线段:

// Create box
const boxGeometry = new THREE.BoxGeometry(1, 1, 1)
const boxMaterial = new THREE.MeshStandardMaterial({
    metalness: 0.3,
    roughness: 0.4,
    envMap: environmentMapTexture,
    envMapIntensity: 0.5
})
const createBox = (width, height, depth, position) =>
{
    // Three.js mesh
    const mesh = new THREE.Mesh(boxGeometry, boxMaterial)
    mesh.scale.set(width, height, depth)
    mesh.castShadow = true
    mesh.position.copy(position)
    scene.add(mesh)

    // Cannon.js body
    const shape = new CANNON.Box(new CANNON.Vec3(width * 0.5, height * 0.5, depth * 0.5))

    const body = new CANNON.Body({
        mass: 1,
        position: new CANNON.Vec3(0, 3, 0),
        shape: shape,
        material: defaultMaterial
    })
    body.position.copy(position)
    world.addBody(body)

    // Save in objects
    objectsToUpdate.push({ mesh, body })
}

createBox(1, 1.5, 2, { x: 0, y: 3, z: 0 })

debugObject.createBox = () =>
{
    createBox(
        Math.random(),
        Math.random(),
        Math.random(),
        {
            x: (Math.random() - 0.5) * 3,
            y: 3,
            z: (Math.random() - 0.5) * 3
        }
    )
}
gui.add(debugObject, 'createBox')
lesson demo video preview

别忘了移除第一次的 createSphere(...) 调用,否则你会同时在同一位置创建一个球体和一个盒子,那可能会乱成一团。

lesson demo video preview

你应该看到一个盒子下落,然后突然穿过地板。如果你点击 lil-gui 的 createBox 按钮,这应该会更明显。

我们忘了一件大事:我们的 mesh 没有旋转。这里发生的是盒子在地板上弹跳,然后侧翻落地。但我们能看到的只是盒子直挺挺地站着并穿过地板,因为 Three.js Mesh 不像 Cannon.js Body 那样旋转。

我们之前没看到这个问题,因为我们用的是球体,无论怎么旋转它们看起来都一样。

我们可以通过把 Body 的 quaternion 复制到 Mesh 的 quaternion 来修复这个问题,就像我们对 position 做的那样:

const tick = () =>
{
    // ...

    for(const object of objectsToUpdate)
    {
        object.mesh.position.copy(object.body.position)
        object.mesh.quaternion.copy(object.body.quaternion)
    }

    // ...
}
lesson demo video preview

盒子现在应该正常下落了。你可以随心所欲地创建球体和盒子。像往常一样,尽量别把你的电脑烧掉。

lesson demo video preview

性能

Broadphase

在测试物体之间的碰撞时,一个天真的做法是把每个 Body 和所有其他 Body 都测一遍。虽然这很容易做,但在性能上是昂贵的。

这正是 broadphase 登场的地方。broadphase 在测试之前对 Body 做一个粗略的分类。想象你有两堆相隔很远的盒子。你为什么要测一堆盒子与另一堆盒子碰撞?它们太远了,不可能碰撞。

Cannon.js 里有 3 种可用的 broadphase 算法:

默认的 broadphase 是 NaiveBroadphase,我建议你切换到 SAPBroadphase。使用这个 broadphase 最终有可能产生碰撞没发生的 bug,但这很罕见,而且它涉及非常快速移动 Body 这类情况。

要切换到 SAPBroadphase,只需在 world.broadphase 属性里实例化它,并且也要用同一个世界作为参数:

world.broadphase = new CANNON.SAPBroadphase(world)

睡眠

即使我们用了改进过的 broadphase 算法,所有的 Body 都会被测试,包括那些不再动的。我们可以使用一个叫 sleep 的特性。

当 Body 的速度变得极其缓慢(慢到你都看不见它在动)时,这个 Body 就可以入睡,并且不会被测试,除非通过代码对它施加足够的力,或者有另一个 Body 撞到它。

要激活这个特性,只需在 World 上把 allowSleep 属性设为 true:

world.allowSleep = true

你也可以用 sleepSpeedLimit 和 sleepTimeLimit 属性控制 Body 入睡的可能性,但我们不会改变那些。

事件

你可以在 Body 上监听事件。如果你想做像物体碰撞时播放声音这样的事,或者你想知道某颗抛射物是不是碰到了敌人,这都很有用。

你可以在 Body 上监听像 'colide'、'sleep' 或 'wakeup' 这样的事件。

当我们球体和盒子与任何东西碰撞时,让我们播放一个撞击声。首先,用原生 JavaScript 创建那个声音,并创建一个应该播放声音的函数。

一些像 Chrome 这样的浏览器会阻止声音播放,除非用户已经与页面有过交互,比如点击过任何地方,所以如果你听不到最开始的声音,别担心。

/**
 * Sounds
 */
const hitSound = new Audio('/sounds/hit.mp3')

const playHitSound = () =>
{
    hitSound.play()
}

光播放一个声音有点勉强,但我们稍后会给这个函数加更多内容。

现在,让我们在我们的 Body 上监听 'collide' 事件。我们将只专注于 createBox 函数,等完成后把它加到 createSphere 函数里。

现在,监听 collide 事件,并用 playHitSound 函数作为回调:

const createBox = (width, height, depth, position) =>
{
    // ...

    body.addEventListener('collide', playHitSound)

    // ...
}
lesson demo video preview

当立方体碰到地面或者立方体互相碰撞时,你应该听到撞击声。如果你用 Chrome,别忘了在盒子碰到地板之前点击一下页面,因为如果还没有用户交互发生,Chrome 会拒绝播放声音。

声音听起来相当不错。可惜,当我们添加多个盒子时会变得十分古怪。

lesson demo video preview

第一个问题是,当声音正在播放时我们调用 hitSound.play(),什么都不会发生,因为它已经在播放了。我们可以用 currentTime 属性把声音重置到 0:

const playHitSound = () =>
{
    hitSound.currentTime = 0
    hitSound.play()
}
lesson demo video preview

虽然一开始这让情况好了一些,但我们听到的撞击声太多了,哪怕只是立方体轻轻擦到另一个立方体。我们需要知道撞击有多强,如果不够强就不播放任何声音。

要得到撞击强度,我们首先需要获得碰撞的信息。我们可以通过给 'collide' 回调(也就是我们的 playHitSound 函数)添加一个参数来做到这一点:

const playHitSound = (collision) =>
{
    console.log(collision)

    // ...
}

collision 变量现在包含一大堆信息。撞击强度可以在 contact 属性上调用 getImpactVelocityAlongNormal() 方法找到:

const playHitSound = (collision) =>
{
    console.log(collision.contact.getImpactVelocityAlongNormal())
    
    // ...
}

如果你看日志,你应该看到一个数字。撞击越强,数字越高。

我们测试那个值,并且只在 impactStrength 足够强时播放声音:

const playHitSound = (collision) =>
{
    const impactStrength = collision.contact.getImpactVelocityAlongNormal()

    if(impactStrength > 1.5)
    {
        hitSound.currentTime = 0
        hitSound.play()
    }
}
lesson demo video preview

为了更逼真,我们可以给声音的音量添加一些随机性:

const playHitSound = (collision) =>
{
    const impactStrength = collision.contact.getImpactVelocityAlongNormal()

    if(impactStrength > 1.5)
    {
        hitSound.volume = Math.random()
        hitSound.currentTime = 0
        hitSound.play()
    }
}
lesson demo video preview

如果我们想更进一步,我们可以有多个略有不同的撞击声。而且为了防止有太多声音同时播放,我们可以添加一个非常短的延迟,让声音播放一次后在短时间内无法再次播放。

这节课我们不会做这些,但随你自由尝试。

让我们把我们用在 createBox 函数里的代码复制到 createSphere 函数里:

const createSphere = (radius, position) =>
{
    // ...

    body.addEventListener('collide', playHitSound)

    // ...
}
lesson demo video preview

这些声音应该对球体也有效了。

移除物体

让我们添加一个 reset 按钮。

创建一个 reset 函数,并按我们为 createBox 和 createSphere 所做的,把它加到你的 lil-gui:

// Reset
debugObject.reset = () =>
{
    console.log('reset')
}
gui.add(debugObject, 'reset')

现在,让我们遍历 objectsToUpdate 数组里的每一个对象。然后把 object.body 从 world 里移除,把 object.mesh 从 scene 里移除。另外,别忘了像你在原生 JavaScript 里会做的那样移除 eventListener:

debugObject.reset = () =>
{
    for(const object of objectsToUpdate)
    {
        // Remove body
        object.body.removeEventListener('collide', playHitSound)
        world.removeBody(object.body)

        // Remove mesh
        scene.remove(object.mesh)
    }
}
lesson demo video preview

我们也需要清空 objectsToUpdate 数组。在 JS 里有很多奇怪的清空数组的方式,其中之一是用 splice 方法把它的内容替换成空。

确保在 for 循环之后做这件事:

debugObject.reset = () =>
{
    for(const object of objectsToUpdate)
    {
        // ...
    }

    objectsToUpdate.splice(0, objectsToUpdate.length)
}

就这些了。你可以点击 reset 按钮移除所有东西。

用 Cannon.js 走得更多

虽然我们覆盖了基础知识,而且你已经能做很多事情了,但这里有一些可以改进的地方。

约束

约束,顾名思义,能在两个 body 之间启用约束。我们这节课不会覆盖它们,但这里是约束的列表:

类、方法、属性和事件

有很多类,每个类都有不同的方法、属性和事件。试着把所有类至少都浏览一遍,只是为了知道它们的存在。它可能会在你未来的项目里为你省下一些时间。

示例

文档并不完美。花些时间看看演示并做点研究,弄清楚怎么做事情会很有帮助。很多人很可能已经遇到过你可能遇到的这些问题了。不要犹豫去依赖社区。

Workers

运行物理模拟需要时间。你电脑里负责做这件事的组件是 CPU。当你运行 Three.js、Cannon.js、你的代码逻辑等等时,这一切都是在你 CPU 的同一个线程里完成的。如果有太多要做的(比如物理模拟里有太多对象),那个线程会很快过载,导致帧率下降。

正确的解决方案是使用 workers。Workers 让你把代码的一部分放到一个不同的线程里来分担负载。然后你可以从那部分代码发送和接收数据。它可以带来相当可观的性能提升。

问题是代码必须明确地被分开。你可以在这个网页源代码里找到不错而且简单的例子。

Cannon-es

正如我们之前说的,Cannon.js 已经很多年没有更新了。幸运的是,有些人 fork 了这个仓库并开始做更新。多亏了他们,我们能用一个更好且受维护的 Cannon.js 版本:

要用这个版本代替原版,在项目文件夹里打开终端(或关闭服务器),用 npm uninstall --save cannon 移除之前的 cannon.js 依赖。

至于 cannon-es,你可以用 npm install cannon-es 安装最新版本,并改变你在代码里导入 Cannon.js 的方式:

import * as CANNON from 'cannon-es'

一切都应该和之前一样正常工作。你可以在这个Git 仓库页面里查看这些改动。

最新版本应该能作为即插即用的替代品,但如果你收到错误,你可以用一个更具体的版本,比如通过运行 npm install cannon-es@0.20 使用 0.20 版本(已经测试过)。

Ammo.js

我们用 Cannon.js 是因为这个库容易实现和理解。它最大的竞争对手之一是 Ammo.js。虽然它更难使用、更难在你的项目里实现,但这里有一些可能让你感兴趣的特性:

如果你需要最好的性能,或者你的项目有特定的特性,你可能应该选 Ammo.js 而不是 Cannon.js。

Physijs

Physijs 简化了在 Three.js 项目里物理的实现。它使用 Ammo.js,并且原生支持 workers。

与其分别创建 Three.js 对象和物理对象,你可以同时创建两者:

box = new Physijs.BoxMesh(
    new THREE.CubeGeometry(5, 5, 5),
    new THREE.MeshBasicMaterial({ color: 0x888888 })
)
scene.add(box)

Physijs 会处理剩下的部分。

虽然这很吸引人,尤其是对初学者,但当你想做一些库不支持的事情时,事情就变得复杂了。找出 bug 从哪里来的也可能很麻烦。

和 Ammo.js 一样,花点时间想想对你的项目来说最好的解决方案是什么。